File: /var/www/kevin-demo/wp-content/plugins/allspice/includes/membership.php
<?php
// includes/membership.php
//
// First-party membership-session bridge.
//
// Bridges the existing Allspice account + entitlement system into a signed, HttpOnly,
// first-party cookie that PHP can read before content and ad scripts initialize. It creates
// no WordPress users, no login system, and no membership tables; the single source of truth
// stays GET /me/entitlements on the Allspice Public API, called server-to-server with the
// reader's own bearer token. Nothing user-specific is ever persisted in WordPress - the
// cookie in the reader's browser is the only state, and it holds no identity.
//
// Flow:
// Allspice ID token (page JS, existing auth discovery)
// -> POST /wp-json/allspice/v1/member-session (same origin)
// -> plugin forwards Authorization + X-Allspice-Ecosystem to /me/entitlements
// -> entitlements validated against the synced memberships.products (never against
// anything the browser claims)
// -> signed cookie set; PHP helpers below read it on later requests.
if (!defined('ABSPATH')) exit;
const ALLSPICE_MEMBER_COOKIE = 'allspice_member_session';
/*
* TTL policy: 24 hours. Long enough that a returning daily reader is never rendered as
* anonymous by PHP before JS can refresh (the 6h original did exactly that); short enough
* that revocation propagates within a day even if the reader never runs JS again. The page
* bundle refreshes proactively when less than a quarter of the TTL remains, so an active
* reader's cookie in practice never expires. Revocation channels, fastest first: explicit
* logout (immediate DELETE, mandatory), entitlement refresh events (minutes), config_version
* rotation (invalidates every cookie at once), TTL (worst case, 24h).
*/
const ALLSPICE_MEMBER_COOKIE_TTL = DAY_IN_SECONDS;
const ALLSPICE_MEMBER_SCHEMA_VERSION = 1;
/* ---------------------------------------------------------------- Public API base (req 16) */
function allspice_public_api_base(): string {
$base = defined('ALLSPICE_PUBLIC_API_BASE')
? (string)ALLSPICE_PUBLIC_API_BASE
: ((defined('ALLSPICE_ENV') && ALLSPICE_ENV === 'dev')
? 'https://api-dev.allspicelabs.com/v1'
: 'https://api.allspicelabs.com/v1');
$base = rtrim($base, '/');
/* Same override pattern as allspice_webhook_base(): dev/prod (or staging) can differ. */
return apply_filters('allspice_public_api_base', $base);
}
/* ------------------------------------------------------------------- Memberships config */
/* Synced memberships config (from theme_policies_cache - NOT a new option). */
function allspice_memberships_config(): array {
if (!function_exists('allspice_theme_policies_cache_get')) return [];
$tp = allspice_theme_policies_cache_get();
$m = $tp['memberships'] ?? [];
return is_array($m) ? $m : [];
}
function allspice_memberships_config_version(): string {
$m = allspice_memberships_config();
return isset($m['config_version']) ? (string)$m['config_version'] : '';
}
/* ------------------------------------------------- Normalized model (schema v2 + legacy) */
/*
* THE one PHP normalizer (its JS twin is normalizeMemberships in the widget's
* membershipPlan.js - keep the rules in step). Accepts both schemas:
*
* NEW (authoritative whenever site_membership_id is a nonempty string):
* config_version, new_signups_enabled, site_membership_id,
* products.included.{mealPlans,recipes,articles,pages,premiumPico[,adFree]},
* products.custom_benefits[], gate_ui.{recipe_card,content_preview,content_immediate,
* join_label,login_label}, gate_style, lock_badges
*
* OLD (temporary migration support): enabled/ready, products as an ARRAY of
* {id|product_id, benefits: string codes}, flat gate_ui title/description or gates[].
*
* Output shape (identical for both):
* configured, new_signups_enabled, site_membership_id, config_version, legacy,
* benefits[]: {id, title, description|null, custom},
* gate_ui: {recipe_card:{title,description}, content_preview:{...}, content_immediate:{...},
* join_label, login_label, closed_signups_message},
* gate_style: sanitized subset, lock_badges: {enabled,label,background_color|null}.
*
* Never mutates the sync payload; unknown/malformed values are dropped, not fatal.
*/
const ALLSPICE_BUILTIN_BENEFITS = [
/* Deterministic built-in order; custom benefits always append after these. */
'mealPlans' => 'Meal plans',
'recipes' => 'Members-only recipes',
'articles' => 'Exclusive articles',
'pages' => 'Member resources',
'premiumPico' => 'Premium Pico',
'adFree' => 'Ad-free browsing',
];
function allspice_membership_clean_text($v): string {
return is_string($v) ? trim($v) : '';
}
function allspice_memberships_normalize_model(?array $m = null): array {
if ($m === null) $m = allspice_memberships_config();
$site_id = allspice_membership_clean_text($m['site_membership_id'] ?? '');
$is_v2 = $site_id !== '';
$legacy_configured = ($m['enabled'] ?? null) === true && ($m['ready'] ?? null) === true;
$benefits = [];
$seen = [];
if ($is_v2) {
$products = isset($m['products']) && is_array($m['products']) ? $m['products'] : [];
$included = isset($products['included']) && is_array($products['included']) ? $products['included'] : [];
foreach (ALLSPICE_BUILTIN_BENEFITS as $key => $fallback_title) {
$entry = isset($included[$key]) && is_array($included[$key]) ? $included[$key] : null;
if ($entry === null || ($entry['enabled'] ?? null) !== true) continue; /* exactly true */
if (isset($seen[$key])) continue;
$seen[$key] = true;
$title = allspice_membership_clean_text($entry['title'] ?? '');
$desc = allspice_membership_clean_text($entry['description'] ?? '');
$benefits[] = [
'id' => $key,
'title' => $title !== '' ? $title : $fallback_title,
'description' => $desc !== '' ? $desc : null,
'custom' => false,
];
}
$custom = isset($products['custom_benefits']) && is_array($products['custom_benefits']) ? $products['custom_benefits'] : [];
foreach ($custom as $c) {
if (!is_array($c)) continue;
$id = allspice_membership_clean_text($c['id'] ?? '');
$title = allspice_membership_clean_text($c['title'] ?? '');
if ($id === '' || $title === '' || isset($seen[$id])) continue; /* dedupe by id */
$seen[$id] = true;
$desc = allspice_membership_clean_text($c['description'] ?? '');
$benefits[] = ['id' => $id, 'title' => $title, 'description' => $desc !== '' ? $desc : null, 'custom' => true];
}
} elseif ($legacy_configured) {
/* Old schema: benefit string codes on the products array, labeled via the fallback map. */
$products = isset($m['products']) && is_array($m['products']) ? $m['products'] : [];
foreach ($products as $p) {
if (!is_array($p) || !isset($p['benefits']) || !is_array($p['benefits'])) continue;
foreach ($p['benefits'] as $code) {
$code = allspice_membership_clean_text($code);
if ($code === '' || isset($seen[$code])) continue;
$seen[$code] = true;
$benefits[] = [
'id' => $code,
'title' => function_exists('allspice_membership_benefit_label')
? allspice_membership_benefit_label($code)
: ucwords(str_replace(['_', '-'], ' ', $code)),
'description' => null,
'custom' => false,
];
}
}
}
/* gate_ui: v2 nested per-type objects; legacy flat title/description and/or gates[]
matched by gate_type. Output is ALWAYS the nested shape so consumers stay schema-blind. */
$raw_ui = isset($m['gate_ui']) && is_array($m['gate_ui']) ? $m['gate_ui'] : [];
$type_copy = static function (string $type) use ($raw_ui): array {
$out = ['title' => '', 'description' => ''];
if (isset($raw_ui[$type]) && is_array($raw_ui[$type])) {
$out['title'] = allspice_membership_clean_text($raw_ui[$type]['title'] ?? '');
$out['description'] = allspice_membership_clean_text($raw_ui[$type]['description'] ?? $raw_ui[$type]['copy'] ?? '');
}
if ($out['title'] === '' || $out['description'] === '') {
/* Legacy gates[]: matched strictly by gate_type - NEVER gates[0]. */
if (isset($raw_ui['gates']) && is_array($raw_ui['gates'])) {
foreach ($raw_ui['gates'] as $g) {
if (!is_array($g) || ($g['gate_type'] ?? '') !== $type) continue;
if ($out['title'] === '') $out['title'] = allspice_membership_clean_text($g['title'] ?? '');
if ($out['description'] === '') $out['description'] = allspice_membership_clean_text($g['copy'] ?? $g['description'] ?? '');
break;
}
}
}
if ($out['title'] === '') $out['title'] = allspice_membership_clean_text($raw_ui['title'] ?? '');
if ($out['description'] === '') $out['description'] = allspice_membership_clean_text($raw_ui['copy'] ?? $raw_ui['description'] ?? '');
return $out;
};
$gate_ui = [
'recipe_card' => $type_copy('recipe_card'),
'content_preview' => $type_copy('content_preview'),
'content_immediate' => $type_copy('content_immediate'),
'join_label' => allspice_membership_clean_text($raw_ui['join_label'] ?? ''),
'login_label' => allspice_membership_clean_text($raw_ui['login_label'] ?? ''),
'closed_signups_message' => allspice_membership_clean_text($raw_ui['closed_signups_message'] ?? ''),
];
$raw_style = isset($m['gate_style']) && is_array($m['gate_style']) ? $m['gate_style'] : [];
$gate_style = [
'background_color' => allspice_sanitize_css_color($raw_style['background_color'] ?? null),
'text_color' => allspice_sanitize_css_color($raw_style['text_color'] ?? null),
'heading_color' => allspice_sanitize_css_color($raw_style['heading_color'] ?? null),
'border_color' => allspice_sanitize_css_color($raw_style['border_color'] ?? null),
'border_width' => allspice_sanitize_clamped_int($raw_style['border_width'] ?? null, 0, 10),
'corner_radius' => allspice_sanitize_clamped_int($raw_style['corner_radius'] ?? null, 0, 50),
'button_background_color' => allspice_sanitize_css_color($raw_style['button_background_color'] ?? null),
'button_text_color' => allspice_sanitize_css_color($raw_style['button_text_color'] ?? null),
'font_family' => allspice_sanitize_gate_font_family($raw_style['font_family'] ?? null),
];
$raw_badges = isset($m['lock_badges']) && is_array($m['lock_badges']) ? $m['lock_badges'] : [];
$badge_label = allspice_membership_clean_text($raw_badges['label'] ?? '');
$lock_badges = [
'enabled' => ($raw_badges['enabled'] ?? null) === true, /* exactly true */
'label' => $badge_label !== '' ? $badge_label : 'Members',
'background_color' => allspice_sanitize_css_color($raw_badges['background_color'] ?? null),
];
return [
'configured' => $is_v2 || $legacy_configured,
'legacy' => !$is_v2 && $legacy_configured,
'new_signups_enabled' => $is_v2 ? (($m['new_signups_enabled'] ?? true) !== false) : true,
'site_membership_id' => $site_id,
'config_version' => isset($m['config_version']) ? (string)$m['config_version'] : '',
'benefits' => $benefits,
'gate_ui' => $gate_ui,
'gate_style' => $gate_style,
'lock_badges' => $lock_badges,
];
}
/* Request-cached normalized model over the live synced config. */
function allspice_memberships_normalized(): array {
if (isset($GLOBALS['allspice_memberships_normalized_cache']) && is_array($GLOBALS['allspice_memberships_normalized_cache'])) {
return $GLOBALS['allspice_memberships_normalized_cache'];
}
$n = allspice_memberships_normalize_model();
$GLOBALS['allspice_memberships_normalized_cache'] = $n;
return $n;
}
function allspice_memberships_normalized_reset(): void {
$GLOBALS['allspice_memberships_normalized_cache'] = null;
}
/* "The membership program is configured" - a valid site_membership_id (v2) or the legacy
enabled+ready pair. new_signups_enabled plays no part here. */
function allspice_memberships_configured(): bool {
$n = allspice_memberships_normalized();
return $n['configured'] === true;
}
function allspice_memberships_new_signups_enabled(): bool {
$n = allspice_memberships_normalized();
return $n['new_signups_enabled'] === true;
}
/* -------------------------------------------------------------- gate_style sanitizers */
/* Strict CSS color: #RGB/#RGBA/#RRGGBB/#RRGGBBAA, or rgb()/rgba() with plain numbers.
Anything else (var(), url(), gradients, expressions, keywords) is rejected -> null. */
function allspice_sanitize_css_color($v) {
if (!is_string($v)) return null;
$v = trim($v);
if ($v === '') return null;
if (preg_match('/^#(?:[0-9a-fA-F]{3}|[0-9a-fA-F]{4}|[0-9a-fA-F]{6}|[0-9a-fA-F]{8})$/', $v)) return strtolower($v);
if (preg_match('/^rgb\(\s*\d{1,3}\s*,\s*\d{1,3}\s*,\s*\d{1,3}\s*\)$/i', $v)) return strtolower(preg_replace('/\s+/', '', $v));
if (preg_match('/^rgba\(\s*\d{1,3}\s*,\s*\d{1,3}\s*,\s*\d{1,3}\s*,\s*(?:0|1|0?\.\d+)\s*\)$/i', $v)) return strtolower(preg_replace('/\s+/', '', $v));
return null;
}
function allspice_sanitize_clamped_int($v, int $min, int $max) {
if (!is_numeric($v)) return null;
$n = (int)round((float)$v);
if ($n < $min) $n = $min;
if ($n > $max) $n = $max;
return $n;
}
/* Initial allow-list only - no arbitrary font stacks into CSS. */
function allspice_sanitize_gate_font_family($v) {
if (!is_string($v)) return null;
$v = strtolower(trim($v));
return in_array($v, ['inherit', 'serif', 'sans-serif', 'monospace'], true) ? $v : null;
}
/* ----------------------------------------------------------- benefits list (checkmarks) */
/*
* Shared benefits <ul> for gates, the landing shortcode, and the CTA block. $limit 0 = all
* (landing surfaces); gates pass the filtered compact limit. Every string is escaped; no
* shortcode execution; returns '' (no empty container) when there are no enabled benefits.
*/
function allspice_membership_benefits_limit(): int {
$limit = (int)apply_filters('allspice_gate_benefits_limit', 5);
return $limit < 0 ? 0 : $limit;
}
function allspice_membership_benefits_html(int $limit = 0): string {
$n = allspice_memberships_normalized();
$benefits = $n['benefits'];
if ($benefits === []) return '';
if ($limit > 0) $benefits = array_slice($benefits, 0, $limit);
/* 16px with a stroke over the fill: the bare 14px fill path read small and thin against
publisher themes' larger body type (live finding, baking4happiness 2026-08-26). Same
treatment as the widget's membership dialog checks. */
$check = '<span class="allspice-membership-benefits__check" aria-hidden="true">'
. '<svg viewBox="0 0 24 24" width="16" height="16" focusable="false" aria-hidden="true">'
. '<path fill="currentColor" stroke="currentColor" stroke-width="1.6" stroke-linejoin="round" d="M9.55 17.05 4.5 12l1.4-1.4 3.65 3.64 8.15-8.14 1.4 1.4z"/></svg>'
. '</span>';
$html = '<ul class="allspice-membership-benefits">';
foreach ($benefits as $b) {
$html .= '<li>' . $check . '<span class="allspice-membership-benefits__text">'
. '<strong>' . esc_html((string)$b['title']) . '</strong>';
if (isset($b['description']) && is_string($b['description']) && $b['description'] !== '') {
$html .= '<span class="allspice-membership-benefits__desc">' . esc_html($b['description']) . '</span>';
}
$html .= '</span></li>';
}
return $html . '</ul>';
}
/* ------------------------------------------------------ shared gate copy + action pieces */
/*
* Gate copy strictly by gate type (recipe_card | content_preview | content_immediate) from
* the normalized model - never gates[0], never shared across types. Per-type fallbacks are
* the schema-v2 contract wording.
*/
function allspice_membership_gate_copy(string $gate_type): array {
$n = allspice_memberships_normalized();
$fallback_title = [
'recipe_card' => 'Unlock this recipe',
'content_preview' => 'Continue reading as a member',
'content_immediate' => 'This content is for members',
];
$fallback_copy = [
'recipe_card' => 'Join to view the full recipe.',
'content_preview' => 'Join for exclusive articles and recipes.',
'content_immediate' => 'Become a member to unlock it.',
];
$c = isset($n['gate_ui'][$gate_type]) && is_array($n['gate_ui'][$gate_type])
? $n['gate_ui'][$gate_type] : ['title' => '', 'description' => ''];
return [
'title' => $c['title'] !== '' ? $c['title'] : ($fallback_title[$gate_type] ?? 'This content is for members'),
'copy' => $c['description'] !== '' ? $c['description'] : ($fallback_copy[$gate_type] ?? 'Join to unlock this content.'),
'join_label' => $n['gate_ui']['join_label'] !== '' ? $n['gate_ui']['join_label'] : 'Become a member',
'login_label' => $n['gate_ui']['login_label'] !== '' ? $n['gate_ui']['login_label'] : 'Already a member? Log in',
];
}
function allspice_membership_closed_signups_message(): string {
$n = allspice_memberships_normalized();
$msg = $n['gate_ui']['closed_signups_message'];
if ($msg === '') $msg = 'Membership is not currently accepting new signups.';
return (string)apply_filters('allspice_membership_closed_signups_message', $msg);
}
/*
* Shared Join/Login action row. new_signups_enabled === false swaps the ACTIVE Join button
* for the closed-signups message - Login always stays (existing members keep full access,
* management and portal; only NEW purchases pause).
*/
function allspice_membership_gate_actions_html(string $join_label, string $login_label): string {
$html = '<div class="allspice-recipe-gate__actions">';
if (allspice_memberships_new_signups_enabled()) {
$html .= '<a class="allspice-recipe-gate__join" href="#allspice-membership" data-allspice-action="open_membership" role="button">'
. esc_html($join_label) . '</a>';
} else {
$html .= '<p class="allspice-recipe-gate__closed">' . esc_html(allspice_membership_closed_signups_message()) . '</p>';
}
$html .= '<a class="allspice-recipe-gate__login" href="#allspice-login" data-allspice-action="open_login" role="button">'
. esc_html($login_label) . '</a>'
. '</div>';
return $html;
}
/* -------------------------------------------------- entitlement -> canonical relationship */
/*
* STRICT relationship reader (Part 3 of the schema-v2 contract): an entitlement maps to the
* canonical site membership only through a real field,
* 1. an explicit siteMembershipId / site_membership_id / canonicalProductId equal to the
* configured site_membership_id;
* 2. the entitlement's own product id EQUAL to site_membership_id (issued directly on the
* canonical id);
* 3. the legacy synced alias map (old schema's monthly/annual product_id declarations).
* NEVER by display name, price interval, array order, or absent metadata. When nothing
* matches, the entitlement grants nothing here (and the caller's telemetry counts it).
*/
function allspice_membership_entitlement_canonical(array $e) {
$n = allspice_memberships_normalized();
$pid = trim((string)($e['productId'] ?? $e['productID'] ?? $e['product_id'] ?? ''));
if ($n['site_membership_id'] !== '') {
$rel = trim((string)($e['siteMembershipId'] ?? $e['site_membership_id'] ?? $e['canonicalProductId'] ?? $e['canonical_product_id'] ?? ''));
if ($rel !== '' && $rel === $n['site_membership_id']) return $n['site_membership_id'];
if ($pid !== '' && $pid === $n['site_membership_id']) return $n['site_membership_id'];
}
if ($pid !== '') {
$map = allspice_memberships_product_map();
if (isset($map[$pid])) return $map[$pid]['canonical'];
}
return null;
}
/*
* CANONICAL PRODUCT CONTRACT (single rule shared by PHP, page JS, and the widget):
* the synced config's product entry id IS the canonical id. Monthly/annual Stripe SALE
* products (real dev shapes: one `prod_XXXX` per interval) are aliases declared on the entry
* (monthly_product_id / annual_product_id / product_ids). Recipes gate on canonical ids, the
* cookie stores canonical ids, and every entitlement product id is canonicalized (sale ->
* canonical) BEFORE comparison. The map below is the only authority - browser-supplied ids
* are never consulted.
*
* Returns: saleOrCanonicalId => ['canonical' => id, 'benefits' => string[]].
*/
function allspice_memberships_product_map(): array {
$m = allspice_memberships_config();
/* SCHEMA V2: site_membership_id IS the canonical id and products is a benefit-flag
dictionary, not sale definitions. The map carries exactly the canonical id (benefit
ids from the normalized model); sale-product relationships come only from the strict
entitlement reader (allspice_membership_entitlement_canonical) - never invented here. */
$site_id = allspice_membership_clean_text($m['site_membership_id'] ?? '');
if ($site_id !== '') {
$n = allspice_memberships_normalize_model($m);
$benefit_ids = array_values(array_map(static function ($b) { return (string)$b['id']; }, $n['benefits']));
return [$site_id => ['canonical' => $site_id, 'benefits' => $benefit_ids]];
}
$products = isset($m['products']) && is_array($m['products']) ? $m['products'] : [];
$map = [];
foreach ($products as $p) {
if (!is_array($p)) continue;
$canonical = trim((string)($p['id'] ?? $p['product_id'] ?? $p['productId'] ?? ''));
if ($canonical === '') continue;
$benefits = [];
if (isset($p['benefits']) && is_array($p['benefits'])) {
foreach ($p['benefits'] as $b) {
$b = trim((string)$b);
if ($b !== '') $benefits[] = $b;
}
}
$benefits = array_values(array_unique($benefits));
$sale_ids = [$canonical];
foreach (['monthly_product_id', 'annual_product_id', 'monthlyProductId', 'annualProductId'] as $k) {
$v = trim((string)($p[$k] ?? ''));
if ($v !== '') $sale_ids[] = $v;
}
foreach (['product_ids', 'productIds', 'stripe_product_ids'] as $k) {
if (isset($p[$k]) && is_array($p[$k])) {
foreach ($p[$k] as $v) {
$v = trim((string)$v);
if ($v !== '') $sale_ids[] = $v;
}
}
}
foreach (array_unique($sale_ids) as $sid) {
$map[$sid] = ['canonical' => $canonical, 'benefits' => $benefits];
}
}
return $map;
}
/* Sale -> canonical, or null when the id is not a membership product at all. */
function allspice_membership_canonicalize(string $product_id) {
$map = allspice_memberships_product_map();
return isset($map[$product_id]) ? $map[$product_id]['canonical'] : null;
}
/* -------------------------------------------------------------------------- Cookie crypto */
/*
* HMAC key derived from the WordPress salts (req 6). Two salts are mixed with a purpose
* label so this key can never collide with anything else derived from the same salts.
*/
function allspice_member_cookie_key(): string {
return hash('sha256', wp_salt('auth') . '|' . wp_salt('secure_auth') . '|allspice-member-session-v1');
}
function allspice_member_b64url_encode(string $bin): string {
return rtrim(strtr(base64_encode($bin), '+/', '-_'), '=');
}
function allspice_member_b64url_decode(string $str) {
$pad = strlen($str) % 4;
if ($pad) $str .= str_repeat('=', 4 - $pad);
return base64_decode(strtr($str, '-_', '+/'), true);
}
/* payload.signature - both parts base64url; signature is HMAC-SHA256 of the payload part. */
function allspice_member_cookie_encode(array $payload): string {
$body = allspice_member_b64url_encode((string)wp_json_encode($payload));
$sig = allspice_member_b64url_encode(hash_hmac('sha256', $body, allspice_member_cookie_key(), true));
return $body . '.' . $sig;
}
function allspice_member_cookie_decode(string $raw) {
$parts = explode('.', $raw, 2);
if (count($parts) !== 2 || $parts[0] === '' || $parts[1] === '') return null;
$expected = allspice_member_b64url_encode(hash_hmac('sha256', $parts[0], allspice_member_cookie_key(), true));
/* Constant-time comparison (req 7). */
if (!hash_equals($expected, $parts[1])) return null;
$json = allspice_member_b64url_decode($parts[0]);
if (!is_string($json)) return null;
$payload = json_decode($json, true);
return is_array($payload) ? $payload : null;
}
function allspice_member_cookie_set(array $payload): void {
$value = allspice_member_cookie_encode($payload);
$expires = (int)($payload['exp'] ?? (time() + ALLSPICE_MEMBER_COOKIE_TTL));
setcookie(ALLSPICE_MEMBER_COOKIE, $value, [
'expires' => $expires,
'path' => '/',
'secure' => is_ssl(),
'httponly' => true,
'samesite' => 'Lax',
]);
}
function allspice_member_cookie_clear(): void {
setcookie(ALLSPICE_MEMBER_COOKIE, '', [
'expires' => time() - HOUR_IN_SECONDS,
'path' => '/',
'secure' => is_ssl(),
'httponly' => true,
'samesite' => 'Lax',
]);
}
/* ------------------------------------------------------------------ Validated read (req 10) */
/*
* Parse + validate the cookie for the CURRENT request. Result is cached per PHP request
* (req 14) and never persisted. Returns the payload array, or null when the cookie is
* absent, tampered, expired, from other settings, or from a stale memberships config.
*/
function allspice_member_session_read() {
static $cached = false;
static $value = null;
if ($cached) return $value;
$cached = true;
$value = null;
$raw = isset($_COOKIE[ALLSPICE_MEMBER_COOKIE]) ? (string)$_COOKIE[ALLSPICE_MEMBER_COOKIE] : '';
if ($raw === '' || strlen($raw) > 4096) return null;
$p = allspice_member_cookie_decode($raw);
if (!is_array($p)) return null;
if ((int)($p['v'] ?? 0) !== ALLSPICE_MEMBER_SCHEMA_VERSION) return null;
if ((int)($p['exp'] ?? 0) <= time()) return null;
$s = function_exists('allspice_opt_get') ? allspice_opt_get() : [];
if ((string)($p['pid'] ?? '') !== (string)($s['partner_id'] ?? '')) return null;
if ((string)($p['did'] ?? '') !== (string)($s['domain_id'] ?? '')) return null;
/* Config rotated (products/benefits may have changed) -> cookie is stale by definition. */
if ((string)($p['cv'] ?? '') !== allspice_memberships_config_version()) return null;
$value = $p;
return $value;
}
/* ------------------------------------------------------------------------ Helpers (req 13) */
function allspice_get_member_access() {
$p = allspice_member_session_read();
if (!is_array($p)) return null;
return [
'products' => isset($p['prods']) && is_array($p['prods']) ? array_values($p['prods']) : [],
'benefits' => isset($p['ben']) && is_array($p['ben']) ? array_values($p['ben']) : [],
'config_version' => (string)($p['cv'] ?? ''),
'issued_at' => (int)($p['iat'] ?? 0),
'expires_at' => (int)($p['exp'] ?? 0),
];
}
function allspice_member_has_product(string $product_id): bool {
$a = allspice_get_member_access();
return $a !== null && in_array($product_id, $a['products'], true);
}
function allspice_member_has_any_product(array $product_ids): bool {
$a = allspice_get_member_access();
if ($a === null) return false;
foreach ($product_ids as $id) {
if (in_array((string)$id, $a['products'], true)) return true;
}
return false;
}
function allspice_member_has_benefit(string $benefit): bool {
$a = allspice_get_member_access();
return $a !== null && in_array($benefit, $a['benefits'], true);
}
/* --------------------------------------------------------------- Page-cache safety (item 7)
*
* CONTRACT: public (gated) responses stay fully cacheable - the gate placeholder is the same
* for every anonymous reader. Any request CARRYING the member cookie bypasses shared page
* caching entirely (cookie presence, not validity: a stale-but-present cookie
* must never poison a shared cache with a member render either way).
*
* Host/CDN configuration this signals to (must ALSO be configured at those layers):
* - WordPress page-cache plugins (WP Rocket, W3TC, BigScoots/LiteSpeed): DONOTCACHEPAGE +
* nocache_headers() below are honored natively; additionally add `allspice_member_session`
* to the plugin's "never cache users with this cookie" list.
* - Cloudflare (incl. APO) / other CDNs: add a Cache Rule bypassing cache when the request
* Cookie header contains `allspice_member_session`.
* Production gating must not be declared safe until this is verified against the actual
* publisher caching stack - no such test has been run yet.
*/
add_action('template_redirect', 'allspice_member_page_cache_bypass', 0);
function allspice_member_page_cache_bypass(): void {
if (empty($_COOKIE[ALLSPICE_MEMBER_COOKIE])) return;
if (!defined('DONOTCACHEPAGE')) define('DONOTCACHEPAGE', true);
if (!defined('DONOTCACHEOBJECT')) define('DONOTCACHEOBJECT', true);
nocache_headers();
}
/*
* WP Rocket, programmatically (first live-site finding, baking4happiness 2026-08-26).
*
* DONOTCACHEPAGE above only stops Rocket STORING a member render. Rocket SERVES its cached
* files from advanced-cache before WordPress runs at all, so a member carrying the cookie
* was still handed the anonymous cached HTML -- ads and gate placeholders included -- and
* the manual "add the cookie in Rocket's settings" step the comment above prescribes had
* predictably not happened. The reject list makes Rocket bypass its cache for any request
* carrying the member cookie; registering it here removes the per-site manual step.
*
* The list is COMPILED into Rocket's config file, so the filter only takes effect after a
* config regeneration. The admin_init hook performs that once (option-flagged); it also
* clears Rocket's page cache that one time, since every cached page predates the rule.
* Deliberately not version-gated to our plugin: if Rocket is installed later, the functions
* appear, the flag is still unset, and the regeneration runs then.
*/
add_filter('rocket_cache_reject_cookies', 'allspice_member_rocket_reject_cookie');
function allspice_member_rocket_reject_cookie($cookies) {
if (!is_array($cookies)) $cookies = [];
$cookies[] = ALLSPICE_MEMBER_COOKIE;
return array_values(array_unique($cookies));
}
add_action('admin_init', 'allspice_member_rocket_config_refresh');
function allspice_member_rocket_config_refresh(): void {
if (!function_exists('rocket_generate_config_file')) return;
if (get_option('allspice_member_rocket_cfg') === '1') return;
update_option('allspice_member_rocket_cfg', '1', false);
rocket_generate_config_file();
if (function_exists('rocket_clean_domain')) rocket_clean_domain();
}
/* ------------------------------------------------------------------------- REST endpoints */
add_action('rest_api_init', 'allspice_member_session_register_routes');
function allspice_member_session_register_routes(): void {
register_rest_route('allspice/v1', '/member-session', [
[
'methods' => 'POST',
'callback' => 'allspice_member_session_rest_post',
'permission_callback' => '__return_true', // auth = the forwarded bearer token
],
[
'methods' => 'GET',
'callback' => 'allspice_member_session_rest_get',
'permission_callback' => '__return_true', // returns a safe summary only
],
[
'methods' => 'DELETE',
'callback' => 'allspice_member_session_rest_delete',
'permission_callback' => '__return_true', // clearing a cookie is harmless
],
]);
}
/* Safe summary - never the cookie value, never a signature, never identity (req 11). */
function allspice_member_session_summary(): array {
$a = allspice_get_member_access();
if ($a === null) {
return ['active' => false, 'config_version' => allspice_memberships_config_version()];
}
return [
'active' => true,
'products' => $a['products'],
'benefits' => $a['benefits'],
'config_version' => $a['config_version'],
'expires_at' => $a['expires_at'],
];
}
/* Item 7: member-session responses are per-reader - never shared-cacheable. */
function allspice_member_session_no_store($data) {
$response = rest_ensure_response($data);
if ($response instanceof WP_REST_Response) {
$response->header('Cache-Control', 'no-store, private');
}
return $response;
}
function allspice_member_session_rest_get() {
return allspice_member_session_no_store(allspice_member_session_summary());
}
function allspice_member_session_rest_delete() {
allspice_member_cookie_clear();
return allspice_member_session_no_store(['ok' => true, 'active' => false]);
}
function allspice_member_session_rest_post(WP_REST_Request $request) {
/* Bearer token from the SAME reader who is asking - forwarded, never stored, never
logged (req 18). */
$authorization = trim((string)$request->get_header('authorization'));
if ($authorization === '' || stripos($authorization, 'bearer ') !== 0 || strlen($authorization) > 8192) {
return new WP_Error('allspice_no_token', 'Missing bearer token.', ['status' => 401]);
}
$ecosystem = trim((string)$request->get_header('x-allspice-ecosystem'));
/* partner/domain come from PLUGIN SETTINGS only - never from the request (req 3). */
$s = function_exists('allspice_opt_get') ? allspice_opt_get() : [];
$partner_id = trim((string)($s['partner_id'] ?? ''));
$domain_id = trim((string)($s['domain_id'] ?? ''));
if ($partner_id === '' || $domain_id === '') {
return new WP_Error('allspice_not_configured', 'Plugin is not configured.', ['status' => 409]);
}
/* Configured = valid site_membership_id (v2) OR legacy enabled+ready. new_signups_enabled
is IRRELEVANT here - existing members must keep validating when signups are closed. */
$product_map = allspice_memberships_product_map();
if (!allspice_memberships_configured() || $product_map === []) {
return new WP_Error('allspice_memberships_not_ready', 'Memberships are not enabled for this site.', ['status' => 409]);
}
/* Server-to-server entitlement check - the single source of truth. Authorization and
X-Allspice-Ecosystem are forwarded EXACTLY as received. */
$url = allspice_public_api_base() . '/me/entitlements?' . http_build_query([
'partnerId' => $partner_id,
'domainId' => $domain_id,
]);
$headers = [
'Accept' => 'application/json',
'Authorization' => $authorization,
];
if ($ecosystem !== '') {
$headers['X-Allspice-Ecosystem'] = $ecosystem;
}
$resp = wp_remote_get($url, ['timeout' => 8, 'headers' => $headers]);
if (is_wp_error($resp)) {
/* Sanitized: transport class only - no URLs with tokens exist here, but stay terse. */
return new WP_Error('allspice_upstream_unreachable', 'Entitlement service unreachable.', ['status' => 502]);
}
$code = (int)wp_remote_retrieve_response_code($resp);
if ($code === 401 || $code === 403) {
/* Expired / invalid / tenant-mismatched token: the API is the judge (req 3). */
allspice_member_cookie_clear();
return new WP_Error('allspice_invalid_token', 'Token was not accepted.', ['status' => 401]);
}
if ($code < 200 || $code >= 300) {
return new WP_Error('allspice_upstream_error', 'Entitlement service error.', ['status' => 502]);
}
$json = json_decode((string)wp_remote_retrieve_body($resp), true);
if (!is_array($json)) {
return new WP_Error('allspice_upstream_error', 'Entitlement service error.', ['status' => 502]);
}
/* Tolerant unwrap: top-level list, {entitlements: []}, or {data: {entitlements: []}}. */
$entitlements = $json;
if (isset($json['entitlements']) && is_array($json['entitlements'])) {
$entitlements = $json['entitlements'];
} elseif (isset($json['data']['entitlements']) && is_array($json['data']['entitlements'])) {
$entitlements = $json['data']['entitlements'];
}
if (!is_array($entitlements)) $entitlements = [];
/* Keep only: status active|active_grace AND a REAL relationship to the configured
membership (strict reader: explicit siteMembershipId/canonicalProductId field, exact
site_membership_id equality, or the legacy synced alias map - never names, intervals
or array order). The canonical id is what goes into the cookie - never the raw
upstream string beyond the exact match. Benefits derive from the synced config only. */
$products = [];
$benefits = [];
foreach ($entitlements as $e) {
if (!is_array($e)) continue;
$status = strtolower(trim((string)($e['status'] ?? '')));
if ($status !== 'active' && $status !== 'active_grace') continue;
$canonical = allspice_membership_entitlement_canonical($e);
if ($canonical === null) continue;
/* Canonical id into the cookie - a monthly SALE entitlement and an annual one both
resolve to the same canonical gate product (item 6). */
$products[$canonical] = true;
foreach (($product_map[$canonical]['benefits'] ?? []) as $b) $benefits[$b] = true;
}
$products = array_keys($products);
$benefits = array_keys($benefits);
if (function_exists('allspice_console_log')) {
/* Shape only: counts, never ids joined to a person, never the token (req 18). */
allspice_console_log('[Allspice] member-session refresh', [
'entitlements_seen' => count($entitlements),
'products_matched' => count($products),
'benefits' => count($benefits),
]);
}
if ($products === []) {
/* Authenticated non-member: no cookie (absence == no access), clear any stale one. */
allspice_member_cookie_clear();
return allspice_member_session_no_store([
'ok' => true,
'active' => false,
'config_version' => allspice_memberships_config_version(),
]);
}
$now = time();
/* ONLY the fields allowed by req 4/5 - no identity, no token, no billing ids. */
$payload = [
'v' => ALLSPICE_MEMBER_SCHEMA_VERSION,
'pid' => $partner_id,
'did' => $domain_id,
'prods' => $products,
'ben' => $benefits,
'cv' => allspice_memberships_config_version(),
'iat' => $now,
'exp' => $now + ALLSPICE_MEMBER_COOKIE_TTL,
];
allspice_member_cookie_set($payload);
return allspice_member_session_no_store([
'ok' => true,
'active' => true,
'products' => $products,
'benefits' => $benefits,
'config_version' => (string)$payload['cv'],
'expires_at' => (int)$payload['exp'],
]);
}